#!/usr/bin/env dart
// Copyright (c) 2018, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

/// Compiles code with DDC and runs the resulting code with either node or
/// chrome.
///
/// The first script supplied should be the one with `main()`.
///
/// Saves the output in the same directory as the sources for convenient
/// inspection, modification or rerunning the code.

import 'dart:io';
import 'dart:ffi';

import 'package:args/args.dart' show ArgParser;
import 'package:dev_compiler/src/compiler/js_names.dart' as js_names;
import 'package:path/path.dart' as p;

enum NullSafety { strict, weak, disabled }

void main(List<String> args) async {
  void printUsage() {
    print('Usage: ddb [options] <dart-script-file>\n');
    print('Compiles <dart-script-file> with the dev_compiler and runs it on a '
        'JS platform.\n'
        'Optionally, mulitple Dart source files can be passed as arguments and '
        'they will be compiled into seperate modules in the order provided. '
        'The order will be treated as an import DAG with the last file as the '
        'application entrypoint. The .dill file outputs from each compile will '
        'be collected and passed as dependency summaries for the next compile '
        'step.');
  }

  // Parse flags.
  var parser = ArgParser(usageLineLength: 80)
    ..addOption('arch',
        abbr: 'a',
        help: "Target platform's machine architecture.",
        allowed: ['x64', 'arm64'])
    ..addOption('binary', abbr: 'b', help: 'Runtime binary path.')
    ..addOption('compile-vm-options',
        help: 'DART_VM_OPTIONS for the compilation VM.')
    ..addFlag('debug',
        abbr: 'd',
        help: 'Use current source instead of built SDK.',
        defaultsTo: false)
    ..addMultiOption('enable-experiment',
        help: 'Run with specified experiments enabled.')
    ..addFlag('help', abbr: 'h', help: 'Display this message.')
    ..addOption('mode',
        help: 'Option to (compile|run|all). Default is all (compile and run).',
        allowed: ['compile', 'run', 'all'],
        defaultsTo: 'all')
    ..addFlag('sound-null-safety',
        help: 'Compile for sound null safety at runtime. Passed through to the '
            'DDC binary. Defaults to false.',
        negatable: true,
        defaultsTo: true)
    ..addFlag('emit-debug-symbols',
        help: 'Pass through flag for DDC, emits debug symbols file along with '
            'the compiled module.',
        defaultsTo: false)
    ..addFlag('null-assertions',
        help: 'Run with assertions that values passed to non-nullable method '
            'parameters are not null.',
        defaultsTo: false)
    ..addFlag('native-null-assertions',
        help: 'Run with assertions on non-nullable values returned from native '
            'APIs.',
        negatable: true)
    ..addFlag('weak-null-safety-errors',
        help: 'Treat weak null safety warnings as errors.', defaultsTo: false)
    ..addFlag('ddc-modules',
        help: 'Emit modules in the ddc format. Currently supported in Chrome '
            '(amd modules by default) and D8 (already uses ddc modules by '
            'default).',
        defaultsTo: false)
    ..addFlag('canary',
        help: 'Enable all compiler features under active development.',
        defaultsTo: false)
    ..addFlag('observe',
        help:
            'Run the compiler in the Dart VM with --observe. Implies --debug.',
        defaultsTo: false)
    ..addOption('out', help: 'Output file.')
    ..addOption('packages', help: 'Where to find a package spec file.')
    ..addOption('port',
        abbr: 'p',
        help: 'Run with the corresponding chrome/V8 debugging port open.',
        defaultsTo: '9222')
    ..addOption('runtime',
        abbr: 'r',
        help: 'Platform to run on (node|d8|chrome).  Default is node.',
        allowed: ['node', 'd8', 'chrome'],
        defaultsTo: 'node')
    ..addFlag('summarize-text',
        help: 'Emit API summary in a .js.txt file.', defaultsTo: false)
    ..addMultiOption('summary',
        abbr: 's',
        help: 'summary file(s) of imported libraries, optionally with module '
            'import path: -s path.sum=js/import/path')
    ..addFlag('verbose',
        abbr: 'v',
        help: 'Echos the commands, arguments, and environment this script is '
            'running.',
        defaultsTo: false)
    ..addOption('vm-service-port',
        help: 'Specify the observatory port. Implied --observe.');

  var options = parser.parse(args);
  if (options['help'] as bool) {
    printUsage();
    print('Available options:');
    print(parser.usage);
    exit(0);
  }
  if (options.rest.isEmpty) {
    print('Dart script file required.\n');
    printUsage();
    exit(1);
  }
  var arch = options['arch'] as String?;
  var debug = options['debug'] as bool ||
      options['observe'] as bool ||
      options.wasParsed('vm-service-port');
  var summarizeText = options['summarize-text'] as bool;
  var binary = options['binary'] as String?;
  var experiments = options['enable-experiment'] as List<String>;
  var summaries = options['summary'] as List<String>;
  var port = int.parse(options['port'] as String);
  var mode = options['mode'] as String;
  var compile = mode == 'compile' || mode == 'all';
  var run = mode == 'run' || mode == 'all';
  var verbose = options['verbose'] as bool;
  var soundNullSafety = options['sound-null-safety'] as bool;
  var emitDebugSymbols = options['emit-debug-symbols'] as bool;
  var nonNullAsserts = options['null-assertions'] as bool;
  var nativeNonNullAsserts = options.wasParsed('native-null-assertions')
      ? options['native-null-assertions'] as bool
      : soundNullSafety;
  var weakNullSafetyErrors = options['weak-null-safety-errors'] as bool;
  var ddcModules = options['ddc-modules'] as bool;
  var canaryFeatures = options['canary'] as bool;
  var sourceFiles = options.rest.map(p.canonicalize);
  var entryPoint = sourceFiles.last;
  var libRoot = p.dirname(entryPoint);
  var basename = p.basenameWithoutExtension(entryPoint);
  var libname = pathToLibName(entryPoint);

  // This is used in the DDC module system and usually corresponds to the
  // entrypoint's path.
  var appname = p.relative(p.withoutExtension(entryPoint));

  // This is used in the DDC module system for multiapp workflows and is
  // stubbed in ddb.
  var uuid = '00000000-0000-0000-0000-000000000000';

  // By default (no `-d`), we use the `dartdevc` binary on the user's path to
  // compute the SDK we use for execution.  I.e., we assume that `dart` is
  // under `$DART_SDK/bin/dart` and use that to find `dartdevc` and related
  // artifacts.  In this mode, this script can run against any installed SDK.
  // If you want to run against a freshly built SDK, that must be first on
  // your path.
  var dartBinary = Platform.resolvedExecutable;
  var dartSdk = p.dirname(p.dirname(dartBinary));

  // In debug mode (`-d`), we run from the `pkg/dev_compiler` sources.  We
  // determine the location via this actual script (i.e., `-d` assumes
  // this script remains under to `tool` sub-directory).
  var toolPath =
      Platform.script.normalizePath().toFilePath(windows: Platform.isWindows);
  var ddcPath = p.dirname(p.dirname(toolPath));
  var dartCheckoutPath = p.dirname(p.dirname(ddcPath));

  /// Runs the [command] with [args] in [environment].
  ///
  /// Will echo the commands to the console before running them when running in
  /// `verbose` mode.
  Future<Process> startProcess(String name, String command, List<String> args,
      [Map<String, String> environment = const {}]) {
    if (verbose) {
      print('Running $name:\n$command ${args.join(' ')}\n');
      if (environment.isNotEmpty) {
        var environmentVariables =
            environment.entries.map((e) => '${e.key}: ${e.value}').join('\n');
        print('With Environment:\n$environmentVariables\n');
      }
    }
    return Process.start(command, args,
        mode: ProcessStartMode.inheritStdio, environment: environment);
  }

  Future<void> runDdc(List<String> ddcArgs) async {
    var observe =
        options.wasParsed('vm-service-port') || options['observe'] as bool;
    var vmServicePort = options.wasParsed('vm-service-port')
        ? '=${options['vm-service-port']}'
        : '';
    var vmOptions = options['compile-vm-options'] as String?;
    var args = <String>[
      ...?vmOptions?.split(' '),
      if (debug) ...[
        if (observe) ...[
          '--enable-vm-service$vmServicePort',
          '--pause-isolates-on-start',
        ],
        '--enable-asserts',
        // Use unbuilt script.  This only works from a source checkout.
        p.join(ddcPath, 'bin', 'dartdevc.dart'),
      ] else
        // Use built snapshot.
        p.join(dartSdk, 'bin', 'snapshots', 'dartdevc.dart.snapshot'),
      ...ddcArgs,
    ];
    var process = await startProcess('DDC', dartBinary, args);
    if (await process.exitCode != 0) exit(await process.exitCode);
  }

  String mod;
  var chrome = false;
  var node = false;
  var d8 = false;
  var runtime = options['runtime'] as String?;
  switch (runtime) {
    case 'node':
      // TODO(nshahan): Cleanup after the ddc module format is used everywhere.
      if (ddcModules) {
        throw ('The combination of `--runtime=$runtime` and `--ddc-modules` '
            'is not supported at this time.');
      }
      node = true;
      mod = 'common';
      break;
    case 'd8':
      d8 = true;
      mod = 'ddc';
      break;
    case 'chrome':
      chrome = true;
      // TODO(nshahan): Cleanup after the ddc module format is used everywhere.
      mod = ddcModules ? 'ddc' : 'amd';
      break;
    default:
      throw Exception('Unexpected runtime: $runtime');
  }

  var sdkRoot = p.dirname(p.dirname(ddcPath));
  var buildDir = resolveBuildDir(sdkRoot, arch);
  var requirePath = p.join(sdkRoot, 'third_party', 'requirejs');
  var sdkOutlineDill = p.join(buildDir,
      soundNullSafety ? 'ddc_outline.dill' : 'ddc_outline_unsound.dill');
  var compileModeDir = [
    canaryFeatures ? 'canary' : 'stable',
    soundNullSafety ? '' : '_unsound',
  ].join();
  var sdkJsPath =
      p.join(buildDir, 'gen', 'utils', 'ddc', compileModeDir, 'sdk', mod);
  var preamblesDir = p.join(
      sdkRoot, 'sdk', 'lib', '_internal', 'js_runtime', 'lib', 'preambles');

  // Print an initial empty line to separate the invocation from the output.
  if (verbose) {
    print('');
  }
  var outputs = <String>[];
  if (compile) {
    var ddcArgs = [
      if (summarizeText) '--summarize-text',
      '--modules=$mod',
      '--dart-sdk-summary=$sdkOutlineDill',
      for (var summary in summaries) '--summary=$summary',
      for (var experiment in experiments) '--enable-experiment=$experiment',
      if (soundNullSafety) '--sound-null-safety' else '--no-sound-null-safety',
      if (options['packages'] != null) '--packages=${options['packages']}',
      if (emitDebugSymbols) '--emit-debug-symbols',
      if (canaryFeatures) '--canary',
    ];
    var summaryFiles = [];
    for (var sourceFile in sourceFiles) {
      var out = p.setExtension(sourceFile, '.js');
      var requestArgs = [
        for (var summary in summaryFiles) '--summary=$summary',
        '-o',
        out,
        sourceFile,
      ];
      await runDdc([...ddcArgs, ...requestArgs]);
      outputs.add(out);
      var summaryFile = p.setExtension(out, '.dill');
      var libname =
          js_names.pathToJSIdentifier(p.basenameWithoutExtension(sourceFile));
      summaryFiles.add('$summaryFile=$libname');
    }
  }

  if (run) {
    if (chrome) {
      String chromeBinary;
      if (binary != null) {
        chromeBinary = binary;
      } else if (Platform.isWindows) {
        chromeBinary =
            'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe';
      } else if (Platform.isMacOS) {
        chromeBinary =
            '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
      } else {
        // Assume Linux
        chromeBinary = 'google-chrome';
      }
      var amdModulePaths = [
        for (var output in outputs)
          '"${pathToLibName(output)}": "${p.withoutExtension(output)}"'
      ].join(',\n        ');

      // Seal the native JavaScript Object prototype to avoid pollution before
      // loading the Dart SDK module.
      var amdModulesHtml = '''
<script src="$preamblesDir/seal_native_object.js"></script>
<script src='$requirePath/require.js'></script>
<script>
  require.config({
    paths: {
        'dart_sdk': '$sdkJsPath/dart_sdk',
        $amdModulePaths
    },
    waitSeconds: 15
  });
  require(['dart_sdk', '$libname'],
        function(sdk, app) {
    'use strict';

    sdk.dart.weakNullSafetyWarnings(
        !($weakNullSafetyErrors || $soundNullSafety));
    sdk.dart.weakNullSafetyErrors($weakNullSafetyErrors);
    sdk.dart.nonNullAsserts($nonNullAsserts);
    sdk.dart.nativeNonNullAsserts($nativeNonNullAsserts);
    sdk._debugger.registerDevtoolsFormatter();
    app.$libname.main([]);
  });
</script>
''';
      var ddcModuleScriptTags = [
        for (var output in outputs) '<script src="$output"></script>'
      ].join('\n');
      var ddcModulesHtml = '''
<script src="$preamblesDir/seal_native_object.js"></script>
<script src="$ddcPath/lib/js/ddc/ddc_module_loader.js"></script>
<script src="$sdkJsPath/dart_sdk.js"></script>
$ddcModuleScriptTags
<script>
let sdk = dart_library.import("dart_sdk");
sdk.dart.weakNullSafetyWarnings(
    !($weakNullSafetyErrors || $soundNullSafety));
sdk.dart.weakNullSafetyErrors($weakNullSafetyErrors);
sdk.dart.nonNullAsserts($nonNullAsserts);
sdk.dart.nativeNonNullAsserts($nativeNonNullAsserts);

dart_library.start("$appname", "$uuid", "$basename", "$libname", false);
</script>
''';
      var htmlFile = p.setExtension(entryPoint, '.html');
      File(htmlFile)
          .writeAsStringSync(ddcModules ? ddcModulesHtml : amdModulesHtml);
      var tmp = p.join(Directory.systemTemp.path, 'ddc');

      var process = await startProcess('Chrome', chromeBinary, [
        '--auto-open-devtools-for-tabs',
        '--allow-file-access-from-files',
        '--remote-debugging-port=$port',
        '--user-data-dir=$tmp',
        htmlFile
      ]);
      if (await process.exitCode != 0) exit(await process.exitCode);
    } else if (node) {
      var nodePath = '$sdkJsPath:$libRoot';
      var runjs = '''
// Seal the native JavaScript Object prototype to avoid pollution before
// loading the Dart SDK module.
delete Object.prototype.__proto__;
Object.seal(Object.prototype);

let source_maps;
try {
  source_maps = require('source-map-support');
  source_maps.install();
} catch(e) {
}
let sdk = require(\"dart_sdk\");
// Create a self reference for JS interop tests that set fields on self.
sdk.dart.global.self = sdk.dart.global;
let main = require(\"./$basename\").$libname.main;
try {
  sdk.dart.weakNullSafetyWarnings(
      !($weakNullSafetyErrors || $soundNullSafety));
  sdk.dart.weakNullSafetyErrors($weakNullSafetyErrors);
  sdk.dart.nonNullAsserts($nonNullAsserts);
  sdk.dart.nativeNonNullAsserts($nativeNonNullAsserts);
  sdk._isolate_helper.startRootIsolate(main, []);
} catch(e) {
  if (!source_maps) {
    console.log('For Dart source maps: npm install source-map-support');
  }
  sdk.core.print(sdk.dart.stackTrace(e));
  process.exit(1);
}
''';
      var nodeFile = p.setExtension(entryPoint, '.run.js');
      File(nodeFile).writeAsStringSync(runjs);
      var nodeBinary = binary ?? 'node';
      var process = await startProcess('Node', nodeBinary,
          ['--inspect=localhost:$port', nodeFile], {'NODE_PATH': nodePath});
      if (await process.exitCode != 0) exit(await process.exitCode);
    } else if (d8) {
      var ddcModuleScriptTags =
          [for (var output in outputs) 'load("$output");'].join('\n');
      var runjs = '''
load("$ddcPath/lib/js/ddc/ddc_module_loader.js");
load("$sdkJsPath/dart_sdk.js");
$ddcModuleScriptTags

let sdk = dart_library.import('dart_sdk');
// Create a self reference for JS interop tests that set fields on self.
sdk.dart.global.self = sdk.dart.global;
sdk.dart.weakNullSafetyWarnings(
    !($weakNullSafetyErrors || $soundNullSafety));
sdk.dart.weakNullSafetyErrors($weakNullSafetyErrors);
sdk.dart.nonNullAsserts($nonNullAsserts);
sdk.dart.nativeNonNullAsserts($nativeNonNullAsserts);

// Invoke main through the d8 preamble to ensure the code is running
// within the fake event loop.
self.dartMainRunner(function () {
  dart_library.start("$appname", "$uuid", "$basename", "$libname", false);
});
''';
      var d8File = p.setExtension(entryPoint, '.d8.js');
      File(d8File).writeAsStringSync(runjs);
      var d8Binary = binary ?? p.join(dartCheckoutPath, _d8executable);
      var process = await startProcess('D8', d8Binary, [
        p.join(preamblesDir, 'seal_native_object.js'),
        p.join(preamblesDir, 'd8.js'),
        d8File
      ]);
      if (await process.exitCode != 0) exit(await process.exitCode);
    }
  }
}

/// Converts a path to the expected default library name DDC will use.
String pathToLibName(String path) =>
    js_names.pathToJSIdentifier(p.withoutExtension(
        p.isWithin(p.current, path) ? p.relative(path) : p.absolute(path)));

String get _d8executable {
  final arch = Abi.current().toString().split('_')[1];
  if (Platform.isWindows) {
    return p.join('third_party', 'd8', 'windows', arch, 'd8.exe');
  } else if (Platform.isLinux) {
    return p.join('third_party', 'd8', 'linux', arch, 'd8');
  } else if (Platform.isMacOS) {
    return p.join('third_party', 'd8', 'macos', arch, 'd8');
  }
  throw UnsupportedError('Unsupported platform.');
}

/// Maps supported unames to supported architectures.
final _resolvedUnames = {
  'arm64': 'arm64',
  'x86_64': 'x64',
  'x64': 'x64',
};

/// Returns the location of the Dart SDK's build directory.
String resolveBuildDir(String sdkRoot, String? architecture) {
  String platformString;
  String archString;
  if (Platform.isMacOS) {
    platformString = 'xcodebuild';
    var resolvedArchitecture = architecture;
    if (architecture == null) {
      var result = Process.runSync('uname', ['-m']).stdout as String;
      final uname = result.trim();
      resolvedArchitecture = _resolvedUnames[uname] ?? uname;
    }
    if (resolvedArchitecture == 'x64') {
      archString = 'ReleaseX64';
    } else if (resolvedArchitecture == 'arm64') {
      archString = 'ReleaseARM64';
    } else {
      throw UnsupportedError(
          'DDB does not currently support the architecture $architecture '
          'on MacOS.');
    }
  } else if (Platform.isLinux || Platform.isWindows) {
    platformString = 'out';
    if (architecture != null && architecture != 'x64') {
      throw UnsupportedError(
          'DDB does not currently support the architecture $architecture '
          'on Windows or Linux.');
    }
    archString = 'ReleaseX64';
  } else {
    throw UnsupportedError('Unsupported platform.');
  }
  return p.join(sdkRoot, platformString, archString);
}
